物件 object
是複合型別 Complex Type
中的主要被提起和想到的。
可能很多人都有聽過一句話:在 JS 中,只要不是原始型別,其他都是物件。
陣列 array
,函式 function
這些都是物件。
前面的篇幅或多或少有帶到物件的概念(像是比較原始型別和複合型別的時候),但我們再來定義一次:什麼是物件?
物件是由一組或多組 鍵 - 值對(key - value pair)組成的資料結構。
鍵值對是什麼意思?我們來看實際的程式碼:
//兩種宣告方式
let obj1 = new Object();
obj1.key = value;
let obj2 = {
key : value,
};
通常我們會把 鍵 稱作該物件的屬性 property
。
上面的兩種宣告方式,一般多用第二種,第二種宣告出來的方式能夠便於進行多組鍵值的宣告,除此之外,兩者宣告出來的物件是等價的。
物件的鍵只會是字串(若非 symbol 情況下會強制轉型為字串),symbol。
從物件取值我們會用像這樣的方式:
let obj = {
value1 : 123,
'value1' : 234,
"this is value2" : 456,
}
console.log(obj.value1);//234
console.log(obj['value1']);//234
console.log(obj["this is value2"]);//456
使用 []
來將鍵包裹起來,或是直接使用 .
來訪問。
可以看到,obj 宣告中 value1 和 'value1' 兩者是被視為同一鍵,而在印出 console 時因為重複宣告而採用後宣告的 234,印證了我們上面說的對物件鍵的字串轉型。
使用 []
或 .
在大多情況下並沒有區別,所有對鍵的訪問都默認為字串類型的鍵,訪問時不能寫成 obj.'value1'
,會直接收到編譯器報錯。而有區別的情況在於鍵的名稱帶有空格時必須使用 []
來做訪問,避免編譯解讀時解讀成其他的語法。
鍵的默認型別甚至作用於純數字的鍵上,要盡量避免這種純數字鍵,可能會看起來很像陣列導致混淆。
let obj = {
1:123,
}
console.log(obj[1]);
console.log(obj['1']);
//合法的,兩個都會印出 123
使用 []
還有一個好處:動態決定屬性名稱。
let obj = {
valueA:'abc',
value1:123,
}
let keyA = 'A';
let keyOne = '1';
console.log(obj['value'+ keyA]);//abc
console.log(obj['value'+ keyOne]);//123
在 []
中,你可以合法的使用變數作為鍵值,字串相加等等操作,能夠提供更大的靈活性。
插入一個討論,我們來定義一下這幾個詞,先搞懂他們的不同,我們再繼續往下走。
屬性(property):物件上的其中一組鍵值對的鍵名稱
函式(function):泛指所有 function()
方法(method):特指物件上的鍵值對中的屬性,該屬性的值恰巧是 function
時,我們會稱其為方法
function foo(){
}
let obj = {
key : value;
bar : foo
}
我們稱 foo()
為函式, bar
為 obj
上的一個方法,而 key
則是 obj
上的一個 屬性。
習慣上來說,函式是一個更廣義的定義,方法特指物件上的函式,用於陳述或實作物件的特定行為表現。
陣列在某些語言中是一種獨立的型別,在 JS 中,他也是陣列,但絕對值得獨立出來講。
let arr = ['a',123,true];
console.log(arr[0]);//a
console.log(arr[1]);//123
console.log(arr[2]);//true
陣列的宣告透過 []
進行,但陣列中的各個值得型別不需要一樣,如同上方例子同時有字串,數字和布林值。
陣列的訪問和物件相仿,但鍵值在陣列中我們慣於稱其為索引值(index),而在 JS 中的陣列為 0-index,意味著陣列的第一個元素的索引值必定為 0。所有索引直接為正整數(除了 0 以外)。
記得,陣列也是物件的一種,所以你當然可以對他新增任意的鍵值組合。
但一般並不鼓勵,容易造成語意不清。且透過這種方式新增的鍵值,並不會影響一個陣列的 .length
所回傳的值。
let arr = [123];
arr.attr1 = 456;
console.log(arr.length);//1
arr['2'] = 567
console.log(arr.length);//3
console.log(arr);//123, undefined, 567
如果你的屬性恰巧定義為一個符合正整數的鍵,則要小心會直接改到陣列的對應索引值的元素,他會被解析為你要針對該索引位置的訪問。
如同上面的例子對 '2'
鍵的訪問直接造成了 undefined
的填充,也改變了陣列的長度。
複製一個物件在進行修改,是物件操作中相當基礎的一項行為。
關於複製,我們可以依據行為分成兩種:淺拷貝與深拷貝。
深淺拷貝關係到的是賦值時傳值或傳址的特性,在 Day 3 的傳遞方法段落中有討論到。
let b = '123';
let a = {
k1:123,
k2:'123',
k3:b,
}
let c = a;
c.k1 = 246;
c.k2 = 246;
c.k3 = 246;
console.log(a.k1, a.k2, a.k3);//246 246 246
console.log(c.k1, c.k2, c.k3);//246 246 246
console.log(a==c, a===c);//true true
在上面的程式碼裡,c 是透過指向 a 複製出來的物件,結果對於 c 的修改,完全反應到了 a 身上。
實際上,c 只是將該變數的儲存位址指向了 a 指向的相同位址,該位址再指向三個屬性。
因為修改的是同一個位址裡的東西,賦予行為實際上是位址的複製,這種情況我們稱作淺拷貝。
深拷貝就是相反概念:賦予時給的是一個同值但不同址的內容,傳的是值。如原始型別:
let a = 'foo';
let b = a;
b = 'bar';
console.log(a, b);//"foo" "bar"
在這個例子中,b 通過複製了 a 指向位址儲存的「值」,儲存於新的位址。
因此 a 和 b 雖然值相同,但址不同,更改 b 的時候,並不會影響的 a 的值,因為修改的位址不同。
所有原始型別在使用賦值賦予的時候皆會是深拷貝。
在 JS 裡,我們也想會有想要對物件這種複合型別進行深拷貝的時候,那麼最簡單的方式如下:
let a = {k1 : 123};
let b = JSON.parse(JSON.stringify(a));
b.k1 = 456;
console.log(a.k1, b.k1);//123, 456
透過內建的 JSON
序列化與反序列化函式,先攤平成字串,再解析為物件。
如上所示,對 b.k1 的修改現在並不影響 a 了。
使用這個方法有幾個缺點:
[]
或 {}
),剖析建立物件時也只會是合法的 JSON 對象,也無法存在處理循環引用的物件
let objA = {};
let objB = {};
objA.key1 = objB;
objB.key1 = objA;
console.log(JSON.stringify(objA));
// Uncaught TypeError: Converting circular structure to JSON
let obj = {
stringType: "123",
numberType: 123,
nullType: null,
undefinedType: undefined,
booleanType: true,
symbolType: Symbol('k1'),
funcType: ()=>{},
objectType: {},
array: [],
};
console.log(JSON.parse(JSON.stringify(obj)));
/*
{
stringType: "123"
numberType: 123,
nullType: null,
booleanType: true,
objectType: { ... },
array: [],
}
*/
let obj = {
bi : 123n
};
console.log(JSON.parse(JSON.stringify(obj)));
// Uncaught TypeError: Do not know how to serialize a BigInt"
let obj = {
infinityNumber: Infinity,
nanNumber: NaN
};
console.log(typeof obj.infinityNumber, typeof obj.nanNumber);
//number number
let dcobj = JSON.parse(JSON.stringify(obj));
console.log(typeof dcobj.infinityNumber, typeof dcobj.nanNumber);
//object object
console.log(dcobj);
//{infinityNumber: null, nanNumber: null}
let obj = {
xmas : new Date('2024-12-25')
}
let dcobj = JSON.parse(JSON.stringify(obj));
console.log(typeof obj.xmas);//"object"
console.log(dcobj, typeof dcobj.xmas);//{xmas: "2024-12-25T00:00:00.000Z"}, "string"
console.log(obj.xmas.toJSON);
//ƒ (){return this.format("iso8601")}
object
, array
, function
等三種,針對 object
與 array
,其實有部分情況的不同,可以和上面 2. 做比較。
const arr = ["123", 123, null, undefined, true, Symbol('k1'), ()=>{}, {}, []];
console.log(JSON.parse(JSON.stringify(arr)));
//["123", 123, null, null, true, null, null, { ... }, []]
在 array 中的轉換,在 object 中會消失的原始類型皆被轉為 null,如 undefined, Symbol 等等。JS 中有兩種展開運算符,但看起來一樣,都是寫作 ...
,分別用在物件上和陣列上。
陣列展開運算符在 ES6 (ES2015)被推出,物件展開運算符在 ES9 (ES2018)被推出。
let a = [123,456, {}];
let b = [...a];
console.log(a == b);
//false
console.log(a[2] == b[2]);
//true
let obj1 = {k1:123,k2:[]};
let obj2 = {...obj1};
console.log(obj1 == obj2);
//false
console.log(obj1.k2 == obj2.k2);
//true
在上面兩個例子,我們會看到雖然透過展開運算符做出的新物件,和原本的物件並不相等,但其實展開運算符的原理就是逐一依值和鍵放入到新的變數裡。
那在放入的過程自然就遵從原始型別與複合型別的特性:傳值或傳址,透過這樣的方式作出的複製,新舊物件本身存放於不同址,但其中的值若為複合型別,則放入的仍只是同一個位址,因此我們通常會稱作這也是一種淺拷貝。
另一個 ES6 引入的語法,實際上做的也是淺拷貝,用於物件的合併。
同鍵以最後出現的值覆蓋前面的。
let obj1 = {k1:123,k2:456};
let obj2 = {k2:135,k3:246};
console.log(Object.assign(obj1,obj2));
//{k1:123,k2:135,k3:246}
//那左邊放空物件是不是就能用來複製?可以
let obj3 = {k1:[],k2:123};
let obj4 = Object.assign({},obj3);
console.log(obj3.k1 == obj4.k1);
//true
//但是也是淺拷貝
上面提的方法在複製時,其實都不是不能用,只是要提出所有需要注意的點,如 JSON 方法中的雷,如果你很確定你的物件並沒有會踩到那些雷的情況,絕對是一個很適合使用的快速方法。
如果你想要一個能夠正常複製 undefined
,NaN
,Symbol
,function
的複製函式?肯定有,但就要用額外的三方函式庫,且要確認該複製行為是否有處理你在意的用例,如 Lodash 就是其中一個被廣泛使用的函式庫。
當然,在自己的專案有獨特需求的時候,且專案規模一定時,會建議自己編寫,撰寫深拷貝的話可以使用遞迴的方式,透過遞迴來確保走訪多層結構且無遺漏。網路上有眾多實作方法,本篇在此不特別提供範例碼,可以去網上參考並觀察各個實作有處理到的行為有哪些。